iT邦幫忙

2021 iThome 鐵人賽

DAY 16
0

在一個應用程式中,有著各種不同類型的資料,這些不同的資料也有屬於他們的生命週期,有些資料就像之前介紹的便利貼一樣,是永久存在雲端上的(除非有一天把專案刪了...),有些資料的生命週期是 App 刪掉才會消失的的,像是登入狀態、或是 UUID 等等。還有些生命週期更短暫的資料,只存在於 App 開啟到關閉的這段時間,更小的還有只存在在一個頁面中的,像是今天要介紹的 Selection state 就是屬於這種。

上述所說資料的生命週期對於架構設計來說是一個很重要的觀念,如果沒有妥善的管理,專案後期將會有大量的 Singleton 在記憶體中,這樣所帶來的後果是,為了資料的正確性,會對 Singleton 做很多建置跟清理的動作,通常來說這不會是一件好事。


再度分析需求

在第二階段中,我們所要新增的新功能是:改變顏色、刪除、新增、改變文字內容。其中新增是最簡單的,只要加一個按鈕即可,使用者便可以很直覺的按下這個按鈕,最後看到新的便利貼出現在螢幕上。但是對於刪除功能的話,對於使用者來說,必需要先選擇要刪除的便利貼是哪一個,才可以進行刪除,而且對於改彥顏色、改變文字內容也是一樣,所以看來我們得要有一個新的概念出現在便利貼中了:那就是選擇狀態。

除了選擇狀態,還要有選擇狀態觸發之後的行動(刪除、更改顏色等等),所以我們還要有一個選單來給使用者做操作,然後在沒有選擇狀態時,使用者才可以新增便利貼,於是我們可以依照以上的這幾點來寫一個粗略的規格出來:

  • 剛進入 App 時,畫面上會有一個新增按鈕,點擊之後可以新增一個便利貼
  • 點擊便利貼時,會進入選擇狀態
    • 進入選擇狀態時,新增按鈕會隱藏起來,並出現選單
    • 再次點擊同一個便利貼時,會取消選擇狀態
    • 點擊其他便利貼時,會切換選擇目標
    • 點擊空白區域時,會取消選擇狀態

接下來我們就依據這些需求去設計 ViewModel 吧!

ViewModel 實作

class EditorViewModel( // [1]
    private val noteRepository: NoteRepository
): ViewModel() {

	val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
  val selectingNote: Observable<Optional<Note>> = TODO() // [2]

  fun moveNote(noteId: String, positionDelta: Position) { ... }
  fun tapNote(note: Note) { TODO() } // [3]
  fun tapCanvas() { TODO() } // [4]

}
  • [1] 我重新命名了 BoardViewModel ,將它改為 EditorViewModel ,因為現在比較像是一個編輯器在做的事了,有選擇狀態,之後也會把改變顏色、刪除、新增便利貼等等也放到這個 ViewModel 。
  • [2] View 需要知道選擇中的便利貼是哪一個,同時這狀態也是一個 Observable ,如此一來就算是切換選擇目標,或是取消該選擇,訂閱該 Observable 的 Observer 會馬上做出反應。
  • [3] 點擊便利貼的公開函式,會影響到選擇狀態
  • [4] 點擊空白處的公開函式,會影響到選擇狀態

首先來看到 selectingNote ,這邊要怎麼來實作呢?以最直覺看法來說,我們只要將 tapNote 中的 note 帶給 selectingNote 就可以了對吧?那要怎麼將資料喂給這個 Observable 呢?還記得我們之前使用過的 BehaviorSubject 嗎?看來又是它可以好好發揮的時候到了:

val selectingNoteSubject = BehaviorSubject.create<Optional<Note>>()
val selectingNote: Observable<Optional<Note>> = selectingNoteSubject.hide()

fun tapNote(note: Note) { 
    selectingNoteSubject.onNext(Optional.of(note))
} 

fun tapCanvas() { 
    selectingNoteSubject.onNext(Optional.empty())
} 

ViewModel 的實作完成了,接下來換到 View 了。

View 實作

再幫大家複習一下之前 BoardView ,boardViewModel 被當作參數放進來,notes 的資料就可以因此跟 ViewModel 做資料綁定。

@Composable
fun BoardView(boardViewModel: BoardViewModel) {
    val notes by boardViewModel.allNotes.subscribeAsState(initial = emptyList())

    Box(Modifier.fillMaxSize()) {
        notes.forEach { note ->
            val onNotePositionChanged: (Position) -> Unit = { delta -> // [1]
                boardViewModel.moveNote(note.id, delta)
            }
            
            StickyNote(
                modifier = Modifier.align(Alignment.Center), // [2]
                onPositionChanged = onNotePositionChanged,
                note = note)
        }
    }
}

但是現在要新增一個按鈕以及便利貼的選單,既有的 UI 已經不符合需求了,需要做相對應的修改,為了讓 BoardView 維持職責單一,選單的部分就不會放到這裡面了,而是會將他們放到一個新的 Composable function : EditorScreen ,他們的關係如下圖所示:

Screen Shot 2021-09-12 at 12.10.15 PM.png

再加上之前的 BoardViewModel 已經重新命名為 EditorViewModel ,這裡再用同樣的作法也不恰當,所以這裡我改成使用參數的方式將這些資料傳遞進來:

@Composable
fun BoardView(
    notesState: State<List<Note>>,
    selectedNoteState: State<Optional<Note>>, // [1]
    updateNotePosition: (String, Position) -> Unit,
    onNoteClicked: (Note) -> Unit // [2]
) {
    val notes by notesState
    val selectedNote by selectedNoteState

    Box(Modifier.fillMaxSize()) {
        notes.forEach { note ->
            val onNotePositionChanged: (Position) -> Unit = { delta ->
                updateNotePosition(note.id, delta)
            }

            val selected = selectedNote.filter { it.id == note.id }.isPresent // [3]

            StickyNote(
                modifier = Modifier.align(Alignment.Center),
                note = note,
                selected = selected,
                onPositionChanged = onNotePositionChanged,
                onClick = onNoteClicked
            )
        }
    }
}

除了之前本來就有的 notesupdateNotePosition 之外,這次新增了 [1] selectedNoteState 跟 [2] onNoteClicked 。selectedNoteState 被當作參數傳進來之後,一樣使用 delegate 的語法 - by 來取值,然後在 notes 的每一個迴圈中檢現在正在顯示的這個便利貼是不是正在選擇的那一個,也就是 [3] 的這個地方,如果剛好吻合的話 selected 的值就會是 true。

Optional 也可以使用 filter? 是的你沒看錯,filter 不是只有 List 或是 Observable 的專利,事實上 Optional 還有更多的 operator 像是 mapflatMap

StickyNote 的實作

// [2]
private val highlightBorder: @Composable Modifier.(Boolean) -> Modifier = { show ->
    if (show) {
        this.border(2.dp, Color.Black, MaterialTheme.shapes.medium)
    } else {
        this
    }.padding(8.dp)
}

@Composable
fun StickyNote(
    modifier: Modifier = Modifier,
    onPositionChanged: (Position) -> Unit = {},
    onClick: (Note) -> Unit,
    note: Note,
    selected: Boolean,
) {
    val offset by animateIntOffsetAsState( // [1]
        targetValue = IntOffset(
            note.position.x.toInt(),
            note.position.y.toInt()
        )
    )

    Surface(
        modifier.offset { offset }
            .size(108.dp, 108.dp)
            .highlightBorder(selected), // [2]
        color = Color(note.color.color),
        elevation = 8.dp
    ) {
        Column(modifier = Modifier
            .clickable { onClick(note) } // [3]
            .pointerInput(note.id) {
                detectDragGestures { change, dragAmount ->
                    change.consumeAllChanges()
                    onPositionChanged(Position(dragAmount.x, dragAmount.y))
                }
            }
            .padding(16.dp)
        ) {
            Text(text = note.text, style = MaterialTheme.typography.h5)
        }
    }
}

以上是修改過後的 StickyNote,首先來看 [1] 這個地方,這邊使用了一個 animation 的 api ,由於移動便利貼時是一連串不連續的資料,所以在螢幕的顯示方面看起來就不是非常的順暢,在使用 animateIntOffsetAsState 之後,藉由動畫的幫助,便利貼的移動方式就變的更加順暢了。

在 [2] 這個地方,我寫了一個 extension function ,為了要顯示 highlight 效果,我必須要額外寫至少 3 - 4 行的程式碼,而這些程式碼如果再放到 StickyNote 這個 function 裡面的話會更難管理,所以在這裡我利用了 modifier 本身的特性,讓 highlight 的功能是用修飾的方式添加在 modifier 裡面,整體看起來可讀性更高。最後 [3] ,使用簡單內建的 clickable 來監聽點擊事件,再傳送到外面給 ViewModel 來決定選擇狀態。

EditorScreen 的實作

@ExperimentalAnimationApi
@Composable
fun EditorScreen(
    viewModel: EditorViewModel,
) {
    Surface(color = MaterialTheme.colors.background) {
        Box(
            Modifier.fillMaxSize()
                .pointerInput("Editor") {
                    detectTapGestures { viewModel.tapCanvas() } // [1]
                }
        ) {
            val selectedNoteState = viewModel.selectingNote.subscribeAsState(initial = Optional.empty())
            val selectedNote by selectedNoteState

            BoardView(
                viewModel.allNotes.subscribeAsState(initial = emptyList()),
                selectedNoteState,
                viewModel::moveNote,
                viewModel::tapNote
            )
            // [2]
            AnimatedVisibility(
                visible = !selectedNote.isPresent,
                modifier = Modifier.align(Alignment.BottomEnd)
            ) {
                FloatingActionButton(
                    onClick = { viewModel.addNewNote() },
                    modifier = Modifier
                        .padding(8.dp)
                ) {
                    val painter = painterResource(id = R.drawable.ic_add)
                    Icon(painter = painter, contentDescription = "Add")
                }
            }
            // [3]
            AnimatedVisibility(
                visible = selectedNote.isPresent,
                modifier = Modifier.align(Alignment.BottomCenter)
            ) {
                MenuView()
            }

        }

    }
}

EditorScreen 的實作就相對單純了:

  • [1] 點擊空白地方的事件由最外面的 Box 來發送
  • [2] 建立新便利貼的按鈕,其能不能顯示在螢幕上是由 selectedNote 的狀態來決定的,如果沒有任何便利貼被選了,就顯示該按鈕
  • [3] 便利貼選單,其顯示的邏輯跟上面相反。

MenuView 的實作

@Composable
fun MenuView(
    modifier: Modifier = Modifier
) {
    var expended by remember {
        mutableStateOf(false)
    }

    val selectedColor = YBColor.HotPink

    Surface(
        modifier = modifier.fillMaxWidth(),
        elevation = 4.dp,
        color = MaterialTheme.colors.surface
    ) {

        Row {
            IconButton(onClick = {} ) {
                val painter = painterResource(id = R.drawable.ic_delete)
                Icon(painter = painter, contentDescription = "Delete")
            }

            IconButton(onClick = {} ) {
                val painter = painterResource(id = R.drawable.ic_text)
                Icon(painter = painter, contentDescription = "Edit text")
            }

            IconButton(onClick = { expended = true }) {
                Box(modifier = Modifier
                    .size(24.dp)
                    .background(Color(selectedColor.color), shape = CircleShape))

                DropdownMenu(expanded = expended, onDismissRequest = { expended = false }) {
                    for (color in YBColor.defaultColors) {
                        DropdownMenuItem(onClick = {
                            expended = false
                            {}
                        }) {
                            Box(modifier = Modifier
                                .size(24.dp)
                                .background(Color(color.color), shape = CircleShape))
                        }
                    }
                }
            }
        }
    }

}

便利貼選單中有三個功能:從左至右分別是刪除、編輯文字、改變顏色選單,相信在看了這麼多 Jetpack Compose 的程式碼之後,這邊應該不太需要我解說才是(其實是我懶XD)。其中最值得一提的是開頭的 var expended by remember 這邊,這邊的狀態是指改顏色的下拉式選單,如果 expended 為 true 的話,下拉式選單就會打開。後面的 remember 是為了讓這個 composable function 在執行 recompose 的時候不會清空 expended 中的值,如果原本的值是 true 的話,recompose 之後也應該要記得是 true。

不了解 recompose 跟 remember 機制的可以參考我之前寫的文章:https://tech.pic-collage.com/8915f95c41f3 (疑,我好像已經工商過一次了XD)

最後再讓大家看看實際 UI 長什麼樣子吧!(請注意這影片是完成第二部後的樣子喔!現在程式的實作還沒做完!)

https://user-images.githubusercontent.com/7949400/124440143-e457ba00-ddac-11eb-93f4-1d3470001528.gif

關於 UI 狀態

今天的內容是 UI 狀態的實作,根據之前在專案架構時所做的決定,我們會把所有的邏輯都放在 ViewModel 中,所以今天所儲存的狀態只停留在 ViewModel 中,沒有再上傳到 Firebase ,於是根據這個觀察,在三層式的架構下,我們可以粗略分成兩種類型的資料,第一種是資料層的資料,他們是會永久存在的,第二種是商業邏輯層的“暫態”資料,當應用程式關掉的時候就會被一起回收掉了,這時候讓我們想想 Single source of truth 這個概念,對於整個 App 來說,Single source of truth 是這個概念只能應用在資料層嗎?也許對於今天 ViewModel 的 UI 狀態來說 ViewModel 層就是一個 Single source of truth。我們在進行架構設計的同時,應該好好來思考一下資料應該要流向哪邊去,每一個元件各自的職責,以及唯一可以信任的資料來源在哪邊,在明天的篇幅中,我們將會繼續來探討這一點。


上一篇
反思與第二部序章
下一篇
ViewModel 的 Single source of truth
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言